/***********************************************************************************************************************
 *
 * BetterBeansBinding - keeping JavaBeans in sync
 * ==============================================
 *
 * Copyright (C) 2009 by Tidalwave s.a.s. (http://www.tidalwave.it)
 * http://betterbeansbinding.kenai.com
 *
 * This is derived work from BeansBinding: http://beansbinding.dev.java.net
 * BeansBinding is copyrighted (C) by Sun Microsystems, Inc.
 *
 ***********************************************************************************************************************
 *
 * This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General
 * Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to
 * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
 *
 ***********************************************************************************************************************
 *
 * $Id: BeanProperty.java 63 2009-06-11 19:48:05Z fabriziogiudici $
 *
 **********************************************************************************************************************/

/*
 *   TO DO LIST:
 *
 *   - Re-think use of PropertyResolutionException.
 *     Many of the cases should be AssertionErrors, because they shouldn't happen.
 *     For the others, we should either use an Error subclass to indicate they're
 *     unrecoverable, or we need to try to leave the object in a consistent state.
 *     This is very difficult in methods like updateCachedSources where an
 *     exception can occur at any time while processing the chain.
 *
 *   - Do testing with applets/security managers.
 *
 *   - Add option to turn on validation. For now it's hard-coded to be off.
 */
package org.jdesktop.beansbinding;

import java.util.IdentityHashMap;
import java.util.Map;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.beans.BeanInfo;
import java.beans.EventSetDescriptor;
import java.beans.IndexedPropertyDescriptor;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.beans.PropertyDescriptor;
import static org.jdesktop.beansbinding.PropertyStateEvent.UNREADABLE;
import org.jdesktop.beansbinding.util.logging.Logger;
import org.jdesktop.beansbinding.ext.BeanAdapterFactory;
import org.jdesktop.observablecollections.ObservableMap;
import org.jdesktop.observablecollections.ObservableMapListener;


/**
 * An implementation of {@code Property} that uses a simple dot-separated path
 * syntax to address Java Beans properties of source objects. For example, to
 * create a property representing the {@code firstName} property of an obect:
 * <p>
 * <pre><code>
 *    BeanProperty.create("firstName");
 *</code></pre>
 * <p>
 * Or to create a property representing the {@code firstName} property of
 * an object's {@code mother} property:
 * <p>
 * <pre><code>
 *    BeanProperty.create("mother.firstName");
 * </code></pre>
 * <p>
 * An instance of {@code BeanProperty} is immutable and can be used with
 * different source objects. When a {@code PropertyStateListener} is added to
 * a {@code BeanProperty} for a given source object, the {@code BeanProperty}
 * starts listening to all objects along the path (based on that source object)
 * for change notification, and reflects any changes by notifying the
 * listener associated with the property for that source object. So, in the second
 * example above, if a {@code PropertyStateListener} is added to the property
 * for an object {@code Duke}, the {@code PropertyStateListener} is notified
 * when either {@code Duke's} mother changes (if the new mother's name is
 * different), or {@code Duke's mother's firstName} changes.
 * <p>
 * It is very important that any bean properties addressed via a {@code BeanProperty}
 * follow the Java Beans specification, including firing property change notification;
 * otherwise, {@code BeanProperty} cannot respond to change. As some beans outside
 * of your control may not follow the Java Beans specification, {@code BeanProperty}
 * always checks the {@link org.jdesktop.beansbinding.ext.BeanAdapterFactory} to
 * see if a delegate provider has been registered to provide a delegate bean to take
 * the place of an object for a given property. See the
 * <a href="ext/package-summary.html">ext package level</a> documentation for more
 * details.
 * <p>
 * When there are no {@code PropertyStateListeners} installed on a {@code BeanProperty}
 * for a given source, all {@code Property} methods act by traversing the entire
 * path from the source to the end point, thereby always providing "live" information.
 * On the contrary, when there are {@code PropertyStateListeners} installed, the beans
 * along the path (including the final value) are cached, and only updated upon
 * notification of change from a bean. Again, this makes it very important that any
 * bean property that could change along the path fires property change notification.
 * <p>
 * <a name="READABILITY"><b>Readability</b></a> of a {@code BeanProperty} for a given source is defined as follows:
 * <i>A {@code BeanProperty} is readable for a given source if and only if
 * a) each bean in the path, starting with the source, defines a Java Beans getter
 * method for the the property to be read on it AND b) each bean in the path,
 * starting with the source and ending with the bean on which we read the final
 * property, is {@code non-null}. The final value being {@code null} does not
 * affect the readability.</i>
 * <p>
 * So, in the second example given earlier, the {@code BeanProperty} is readable for (@code Duke} when all
 * of the following are true: {@code Duke} defines a Java Beans getter for
 * {@code mother}, {@code Duke's mother} defines a Java Beans getter for
 * {@code firstName}, {@code Duke} is {@code non-null}, {@code Duke's mother}
 * is {@code non-null}. The {@code BeanProperty} is therefore unreadable when
 * any of the following is true: {@code Duke} does not define a Java Beans
 * getter for {@code mother}, {@code Duke's mother} does not define a Java
 * Beans getter for {@code firstName}, {@code Duke} is {@code null},
 * {@code Duke's mother} is {@code null}.
 * <p>
 * <a name="WRITEABILITY"><b>Writeability</b></a> of a {@code BeanProperty} for a given source is defined as follows:
 * <i>A {@code BeanProperty} is writeable for a given source if and only if
 * a) each bean in the path, starting with the source and ending with the bean on
 * which we set the final property, defines a Java Beans getter method for the
 * property to be read on it AND b) the bean on which we set the final property
 * defines a Java Beans setter for the property to be set on it AND c) each bean
 * in the path, starting with the source and ending with the bean on which we
 * set the final property, is {@code non-null}. The final value being {@code null}
 * does not affect the writeability.</i>
 * <p>
 * So, in the second example given earlier, the {@code BeanProperty} is writeable for {@code Duke} when all
 * of the following are true: {@code Duke} defines a Java Beans getter for
 * {@code mother}, {@code Duke's mother} defines a Java Beans setter for
 * {@code firstName}, {@code Duke} is {@code non-null}, {@code Duke's mother}
 * is {@code non-null}. The {@code BeanProperty} is therefore unreadable when
 * any of the following is true: {@code Duke} does not define a Java Beans
 * getter for {@code mother}, {@code Duke's mother} does not define a Java
 * Beans setter for {@code firstName}, {@code Duke} is {@code null},
 * {@code Duke's mother} is {@code null}.
 * <p>
 * In addition to working on Java Beans properties, any object in the path
 * can be an instance of {@code Map}. In this case, the {@code Map's get}
 * method is used with the property name as the getter, and the
 * {@code Map's put} method is used with the property name as the setter.
 * {@code BeanProperty} can only respond to changes in {@code Maps}
 * if they are instances of {@link org.jdesktop.observablecollections.ObservableMap}.
 * <p>
 * Some methods in this class document that they can throw
 * {@code PropertyResolutionException} if an exception occurs while trying
 * to resolve the path. The throwing of this exception represents an abnormal
 * condition and if listeners are installed for the given source object,
 * leaves the {@code BeanProperty} in an inconsistent state for that source object.
 * A {@code BeanProperty} should not be used again for that same source object
 * after such an exception without first removing all listeners associated with
 * the {@code BeanProperty} for that source object.
 *
 * @param <S> the type of source object that this {@code BeanProperty} operates on
 * @param <V> the type of value that this {@code BeanProperty} represents
 *
 * @author Shannon Hickey
 * @author Scott Violet
 * @author Fabrizio Giudici
 */
public final class BeanProperty<S, V> extends PropertyHelper<S, V> {
    private static final String CLASS = ELProperty.class.getName();
    private static final Logger logger = Logger.getLogger(CLASS);
    private static final Object NOREAD = new Object();
    private Property<S, ?> baseProperty;
    private final PropertyPath path;
    private IdentityHashMap<S, SourceEntry> map = new IdentityHashMap<S, SourceEntry>();

    /**
     * @throws IllegalArgumentException for empty or {@code null} path.
     */
    private BeanProperty(Property<S, ?> baseProperty, String path) {
        this.path = PropertyPath.createPropertyPath(path);
        this.baseProperty = baseProperty;
    }

    /**
     * Creates an instance of {@code BeanProperty} for the given path.
     *
     * @param path the path
     * @return an instance of {@code BeanProperty} for the given path
     * @throws IllegalArgumentException if the path is null, or contains
     *         no property names
     */
    public static final <S, V> BeanProperty<S, V> create(String path) {
        return new BeanProperty<S, V>(null, path);
    }

    /**
     * Creates an instance of {@code BeanProperty} for the given base property
     * and path. The path is relative to the value of the base property.
     *
     * @param baseProperty the base property
     * @param path the path
     * @return an instance of {@code BeanProperty} for the given base property and path
     * @throws IllegalArgumentException if the path is null, or contains
     *         no property names
     */
    public static final <S, V> BeanProperty<S, V> create(
        Property<S, ?> baseProperty, String path) {
        return new BeanProperty<S, V>(baseProperty, path);
    }

    private Object getLastSource(S source) {
        Object src = getBeanFromSource(source);

        if ((src == null) || (src == NOREAD)) {
            return src;
        }

        for (int i = 0; i < (path.length() - 1); i++) {
            src = getProperty(src, path.get(i));

            if (src == null) {
                logger.warning("getLastSource(): missing source");

                return null;
            }

            if (src == NOREAD) {
                logger.warning("getLastSource(): missing read method");

                return NOREAD;
            }
        }

        return src;
    }

    /**
     * {@inheritDoc}
     * <p>
     * See the class level documentation for the definition of <a href="#WRITEABILITY">writeability</a>.
     *
     * @throws UnsupportedOperationException {@inheritDoc}
     * @throws PropertyResolutionException if an exception occurs while resolving the path
     * @see #setValue
     * @see #isWriteable
     */
    public Class<?extends V> getWriteType(S source) {
        SourceEntry entry = map.get(source);

        if (entry != null) {
            entry.validateCache(-1);

            if (entry.cachedWriter == null) {
                throw new UnsupportedOperationException("Unwriteable");
            }

            return (Class<?extends V>) getType(entry.cache[path.length() - 1],
                path.getLast());
        }

        return (Class<?extends V>) getType(getLastSource(source), path.getLast());
    }

    /**
     * {@inheritDoc}
     * <p>
     * See the class level documentation for the definition of <a href="#READABILITY">readability</a>.
     *
     * @throws UnsupportedOperationException {@inheritDoc}
     * @throws PropertyResolutionException if an exception occurs while resolving the path
     * @see #isReadable
     */
    public V getValue(S source) {
        SourceEntry entry = map.get(source);

        if (entry != null) {
            entry.validateCache(-1);

            if (entry.cachedValue == NOREAD) {
                throw new UnsupportedOperationException("Unreadable");
            }

            return (V) entry.cachedValue;
        }

        Object src = getLastSource(source);

        if ((src == null) || (src == NOREAD)) {
            throw new UnsupportedOperationException("Unreadable");
        }

        src = getProperty(src, path.getLast());

        if (src == NOREAD) {
            logger.severe("getValue(): missing read method");
            throw new UnsupportedOperationException("Unreadable");
        }

        return (V) src;
    }

    /**
     * {@inheritDoc}
     * <p>
     * See the class level documentation for the definition of <a href="#WRITEABILITY">writeability</a>.
     *
     * @throws UnsupportedOperationException {@inheritDoc}
     * @throws PropertyResolutionException if an exception occurs while resolving the path
     * @see #isWriteable
     * @see #getWriteType
     */
    public void setValue(S source, V value) {
        SourceEntry entry = map.get(source);

        if (entry != null) {
            entry.validateCache(-1);

            if (entry.cachedWriter == null) {
                throw new UnsupportedOperationException("Unwritable");
            }

            try {
                entry.ignoreChange = true;
                write(entry.cachedWriter, entry.cache[path.length() - 1],
                    path.getLast(), value);
            } finally {
                entry.ignoreChange = false;
            }

            Object oldValue = entry.cachedValue;
            entry.updateCachedValue();
            notifyListeners(entry.cachedIsWriteable(), oldValue, entry);
        } else {
            setProperty(getLastSource(source), path.getLast(), value);
        }
    }

    /**
     * {@inheritDoc}
     * <p>
     * See the class level documentation for the definition of <a href="#READABILITY">readability</a>.
     *
     * @throws UnsupportedOperationException {@inheritDoc}
     * @throws PropertyResolutionException if an exception occurs while resolving the path
     * @see #isWriteable
     */
    public boolean isReadable(S source) {
        SourceEntry entry = map.get(source);

        if (entry != null) {
            entry.validateCache(-1);

            return entry.cachedIsReadable();
        }

        Object src = getLastSource(source);

        if ((src == null) || (src == NOREAD)) {
            return false;
        }

        Object reader = getReader(src, path.getLast());

        if (reader == null) {
            logger.warning("isReadable(): missing read method");

            return false;
        }

        return true;
    }

    /**
     * {@inheritDoc}
     * <p>
     * See the class level documentation for the definition of <a href="#WRITEABILITY">writeability</a>.
     *
     * @throws UnsupportedOperationException {@inheritDoc}
     * @throws PropertyResolutionException if an exception occurs while resolving the path
     * @see #isReadable
     */
    public boolean isWriteable(S source) {
        SourceEntry entry = map.get(source);

        if (entry != null) {
            entry.validateCache(-1);

            return entry.cachedIsWriteable();
        }

        Object src = getLastSource(source);

        if ((src == null) || (src == NOREAD)) {
            return false;
        }

        Object writer = getWriter(src, path.getLast());

        if (writer == null) {
            logger.warning("isWritable(): missing write method");

            return false;
        }

        return true;
    }

    private Object getBeanFromSource(S source) {
        if (baseProperty == null) {
            if (source == null) {
                logger.warning("getBeanFromSource(): source is null");
            }

            return source;
        }

        if (!baseProperty.isReadable(source)) {
            logger.warning("getBeanFromSource(): unreadable source property");

            return NOREAD;
        }

        Object bean = baseProperty.getValue(source);

        if (bean == null) {
            logger.warning("getBeanFromSource(): source property returned null");

            return null;
        }

        return bean;
    }

    @Override
    protected final void listeningStarted(S source) {
        SourceEntry entry = map.get(source);

        if (entry == null) {
            entry = new SourceEntry(source);
            map.put(source, entry);
        }
    }

    @Override
    protected final void listeningStopped(S source) {
        SourceEntry entry = map.remove(source);

        if (entry != null) {
            entry.cleanup();
        }
    }

    private static boolean didValueChange(Object oldValue, Object newValue) {
        return (oldValue == null) || (newValue == null) ||
        !oldValue.equals(newValue);
    }

    private void notifyListeners(boolean wasWriteable, Object oldValue,
        SourceEntry entry) {
        PropertyStateListener[] listeners = getPropertyStateListeners(entry.source);

        if ((listeners == null) || (listeners.length == 0)) {
            return;
        }

        oldValue = toUNREADABLE(oldValue);

        Object newValue = toUNREADABLE(entry.cachedValue);
        boolean valueChanged = didValueChange(oldValue, newValue);
        boolean writeableChanged = (wasWriteable != entry.cachedIsWriteable());

        if (!valueChanged && !writeableChanged) {
            return;
        }

        PropertyStateEvent pse = new PropertyStateEvent(this, entry.source,
                valueChanged, oldValue, newValue, writeableChanged,
                entry.cachedIsWriteable());

        this.firePropertyStateChange(pse);
    }

    /**
     * Returns a string representation of the {@code BeanProperty}. This
     * method is intended to be used for debugging purposes only, and
     * the content and format of the returned string may vary between
     * implementations. The returned string may be empty but may not
     * be {@code null}.
     *
     * @return a string representation of this {@code BeanProperty}
     */
    @Override
    public String toString() {
        return getClass().getName() + "[" + path + "]";
    }

    /**
     * @throws PropertyResolutionException
     */
    private static BeanInfo getBeanInfo(Object object) {
        assert object != null;

        try {
            return Introspector.getBeanInfo(object.getClass());
        } catch (IntrospectionException ie) {
            throw new PropertyResolutionException(
                "Exception while introspecting " + object.getClass().getName(),
                ie);
        }
    }

    /**
     * Returns a public form of the given method for the given class.
     * <p>
     * If a {@code PropertyDescriptor} is obtained for a non-public class that
     * implements a public interface, the read/write methods will be for the
     * class, and therefore inaccessible. To correct this, a version of the
     * same method must be found in a superclass or interface.
     *
     * @param cl the class on which to search for the method
     * @param method the method
     * @return a public form of the given method for the given class,
     *         or {@code null} if one can't be found
     */
    static private Method getPublicForm(Class cl, Method method) {
        if (method == null) {
            return null;
        }

        if (Modifier.isPublic(cl.getModifiers())) {
            return method;
        }

        for (Class c : cl.getInterfaces()) {
            Method m = null;

            try {
                m = c.getMethod(method.getName(), method.getParameterTypes());
                c = m.getDeclaringClass();

                if ((m = getPublicForm(c, m)) != null) {
                    return m;
                }
            } catch (NoSuchMethodException ex) {
            }
        }

        Class c = cl.getSuperclass();

        if (c != null) {
            Method m = null;

            try {
                m = c.getMethod(method.getName(), method.getParameterTypes());
                c = m.getDeclaringClass();

                if ((m = getPublicForm(c, m)) != null) {
                    return m;
                }
            } catch (NoSuchMethodException ex) {
            }
        }

        return null;
    }

    /**
     * @throws PropertyResolutionException
     */
    private static PropertyDescriptor getPropertyDescriptor(Object object,
        String string) {
        assert object != null;

        PropertyDescriptor[] pds = getBeanInfo(object).getPropertyDescriptors();

        if (pds == null) {
            return null;
        }

        for (PropertyDescriptor pd : pds) {
            if (!(pd instanceof IndexedPropertyDescriptor) &&
                    pd.getName().equals(string)) {
                return pd;
            }
        }

        return null;
    }

    private static EventSetDescriptor getEventSetDescriptor(Object object) {
        assert object != null;

        EventSetDescriptor[] eds = getBeanInfo(object).getEventSetDescriptors();

        for (EventSetDescriptor ed : eds) {
            if (ed.getListenerType() == PropertyChangeListener.class) {
                return ed;
            }
        }

        return null;
    }

    /**
     * @throws PropertyResolutionException
     */
    private static Object invokeMethod(Method method, Object object,
        Object... args) {
        Exception reason = null;

        try {
            return method.invoke(object, args);
        } catch (IllegalArgumentException ex) {
            reason = ex;
        } catch (IllegalAccessException ex) {
            reason = ex;
        } catch (InvocationTargetException ex) {
            reason = ex;
        }

        throw new PropertyResolutionException("Exception invoking method " +
            method + " on " + object, reason);
    }

    private Object getReader(Object object, String string) {
        assert object != null;

        if (object instanceof Map) {
            return object;
        }

        object = getAdapter(object, string);

        PropertyDescriptor pd = getPropertyDescriptor(object, string);

        return (pd == null) ? null
                            : getPublicForm(object.getClass(),
            pd.getReadMethod());
    }

    /**
     * @throws PropertyResolutionException
     */
    private Object read(Object reader, Object object, String string) {
        assert reader != null;

        if (reader instanceof Map) {
            assert reader == object;

            return ((Map) reader).get(string);
        }

        object = getAdapter(object, string);

        return invokeMethod((Method) reader, object);
    }

    /**
     * @throws PropertyResolutionException
     */
    private Object getProperty(Object object, String string) {
        if ((object == null) || (object == NOREAD)) {
            return NOREAD;
        }

        Object reader = getReader(object, string);

        if (reader == null) {
            return NOREAD;
        }

        return read(reader, object, string);
    }

    /**
     * @throws PropertyResolutionException
     */
    private Class<?> getType(Object object, String string) {
        if ((object == null) || (object == NOREAD)) {
            throw new UnsupportedOperationException("Unwritable");
        }

        if (object instanceof Map) {
            return Object.class;
        }

        object = getAdapter(object, string);

        PropertyDescriptor pd = getPropertyDescriptor(object, string);

        if ((pd == null) ||
                (getPublicForm(object.getClass(), pd.getWriteMethod()) == null)) {
            logger.severe("getType(): missing write method");
            throw new UnsupportedOperationException("Unwritable");
        }

        return pd.getPropertyType();
    }

    private Object getWriter(Object object, String string) {
        assert object != null;

        if (object instanceof Map) {
            return object;
        }

        object = getAdapter(object, string);

        PropertyDescriptor pd = getPropertyDescriptor(object, string);

        return (pd == null) ? null
                            : getPublicForm(object.getClass(),
            pd.getWriteMethod());
    }

    /**
     * @throws PropertyResolutionException
     */
    private void write(Object writer, Object object, String string, Object value) {
        assert writer != null;

        if (writer instanceof Map) {
            assert writer == object;
            ((Map) writer).put(string, value);

            return;
        }

        object = getAdapter(object, string);

        invokeMethod((Method) writer, object, value);
    }

    /**
     * @throws PropertyResolutionException
     * @throws IllegalStateException
     */
    private void setProperty(Object object, String string, Object value) {
        if ((object == null) || (object == NOREAD)) {
            throw new UnsupportedOperationException("Unwritable");
        }

        Object writer = getWriter(object, string);

        if (writer == null) {
            logger.severe("setProperty(): missing write method");
            throw new UnsupportedOperationException("Unwritable");
        }

        write(writer, object, string, value);
    }

    private static Object toUNREADABLE(Object src) {
        return (src == NOREAD) ? UNREADABLE : src;
    }

    private void registerListener(Object object, String property,
        SourceEntry entry) {
        assert object != null;

        if (object != NOREAD) {
            if (object instanceof ObservableMap) {
                ((ObservableMap) object).addObservableMapListener(entry);
            } else if (!(object instanceof Map)) {
                object = getAdapter(object, property);
                addPropertyChangeListener(object, entry);
            }
        }
    }

    /**
     * @throws PropertyResolutionException
     */
    private void unregisterListener(Object object, String property,
        SourceEntry entry) {
        if ((object != null) && (object != NOREAD)) {
            if (object instanceof ObservableMap) {
                ((ObservableMap) object).removeObservableMapListener(entry);
            } else if (!(object instanceof Map)) {
                object = getAdapter(object, property);
                removePropertyChangeListener(object, entry);
            }
        }
    }

    /**
     * @throws PropertyResolutionException
     */
    private static void addPropertyChangeListener(Object object,
        PropertyChangeListener listener) {
        EventSetDescriptor ed = getEventSetDescriptor(object);
        Method addPCMethod = null;

        if ((ed == null) || ((addPCMethod = getPublicForm(object.getClass(), ed.getAddListenerMethod())) == null)) {
            logger.warning("addPropertyChangeListener(): can't add listener");
            return;
        }

        invokeMethod(addPCMethod, object, listener);
    }

    /**
     * @throws PropertyResolutionException
     */
    private static void removePropertyChangeListener(Object object,
        PropertyChangeListener listener) {
        EventSetDescriptor ed = getEventSetDescriptor(object);
        Method removePCMethod = null;

        if ((ed == null) || ((removePCMethod = getPublicForm(object.getClass(), ed.getRemoveListenerMethod())) == null)) {
            logger.warning("removePropertyChangeListener()", "can't remove listener from source");
            return;
        }

        invokeMethod(removePCMethod, object, listener);
    }

    private static boolean wrapsLiteral(Object o) {
        assert o != null;

        return o instanceof String || o instanceof Byte ||
        o instanceof Character || o instanceof Boolean || o instanceof Short ||
        o instanceof Integer || o instanceof Long || o instanceof Float ||
        o instanceof Double;
    }

    // need special match method because when using reflection
    // to get a primitive value, the value is always wrapped in
    // a new object
    private static boolean match(Object a, Object b) {
        if (a == b) {
            return true;
        }

        if (a == null) {
            return false;
        }

        if (wrapsLiteral(a)) {
            return a.equals(b);
        }

        return false;
    }

    private Object getAdapter(Object o, String property) {
        Object adapter = null;
        adapter = BeanAdapterFactory.getAdapter(o, property);

        return (adapter == null) ? o : adapter;
    }

    private final class SourceEntry implements PropertyChangeListener,
        ObservableMapListener, PropertyStateListener {
        private S source;
        private Object cachedBean;
        private Object[] cache;
        private Object cachedValue;
        private Object cachedWriter;
        private boolean ignoreChange;

        private SourceEntry(S source) {
            this.source = source;
            cache = new Object[path.length()];
            cache[0] = NOREAD;

            if (baseProperty != null) {
                baseProperty.addPropertyStateListener(source, this);
            }

            updateCachedBean();
            updateCachedSources(0);
            updateCachedValue();
            updateCachedWriter();
        }

        private void cleanup() {
            for (int i = 0; i < path.length(); i++) {
                unregisterListener(cache[i], path.get(i), this);
            }

            if (baseProperty != null) {
                baseProperty.removePropertyStateListener(source, this);
            }

            cachedBean = null;
            cache = null;
            cachedValue = null;
            cachedWriter = null;
        }

        private boolean cachedIsReadable() {
            return cachedValue != NOREAD;
        }

        private boolean cachedIsWriteable() {
            return cachedWriter != null;
        }

        private int getSourceIndex(Object object) {
            for (int i = 0; i < cache.length; i++) {
                if (cache[i] == object) {
                    return i;
                }
            }

            if (object instanceof Map) {
                return -1;
            }

            for (int i = 0; i < cache.length; i++) {
                if (cache[i] != null) {
                    Object adapter = getAdapter(cache[i], path.get(i));

                    if (adapter == object) {
                        return i;
                    }
                }
            }

            return -1;
        }

        private void updateCachedBean() {
            cachedBean = getBeanFromSource(source);
        }

        private void updateCachedSources(int index) {
            boolean loggedYet = false;

            Object src;

            if (index == 0) {
                src = cachedBean;

                if (cache[0] != src) {
                    unregisterListener(cache[0], path.get(0), this);

                    cache[0] = src;

                    if (src == null) {
                        loggedYet = true;
                        logger.warning("updateCachedSources(): source is null");
                    } else {
                        registerListener(src, path.get(0), this);
                    }
                }

                index++;
            }

            for (int i = index; i < path.length(); i++) {
                Object old = cache[i];
                src = getProperty(cache[i - 1], path.get(i - 1));

                if (src != old) {
                    unregisterListener(old, path.get(i), this);

                    cache[i] = src;

                    if (src == null) {
                        if (!loggedYet) {
                            loggedYet = true;
                            logger.warning("updateCachedSources(): missing source");
                        }
                    } else if (src == NOREAD) {
                        if (!loggedYet) {
                            loggedYet = true;
                            logger.warning("updateCachedSources(): missing read method");
                        }
                    } else {
                        registerListener(src, path.get(i), this);
                    }
                }
            }
        }

        // -1 already used to mean validate all
        // 0... means something in the path changed
        private void validateCache(int ignore) {
            /* In the future, this debugging code can be enabled via a flag */

            /*
                        for (int i = 0; i < path.length() - 1; i++) {
                            if (i == ignore - 1) {
                                continue;
                            }

                            Object src = cache[i];

                            if (src == NOREAD) {
                                return;
                            }

                            Object next = getProperty(src, path.get(i));

                            if (!match(next, cache[i + 1])) {
                                log("validateCache()", "concurrent modification");
                            }
                        }

                        if (path.length() != ignore) {
                            Object next = getProperty(cache[path.length() - 1], path.getLast());
                            if (!match(cachedValue, next)) {
                                log("validateCache()", "concurrent modification");
                            }

                            Object src = cache[path.length() - 1];
                            Object writer;
                            if (src == null || src == NOREAD) {
                                writer = null;
                            } else {
                                writer = getWriter(cache[path.length() - 1], path.getLast());
                            }

                            if (cachedWriter != writer && (cachedWriter == null || !cachedWriter.equals(writer))) {
                                log("validateCache()", "concurrent modification");
                            }
                        }
             */
        }

        private void updateCachedWriter() {
            Object src = cache[path.length() - 1];

            if ((src == null) || (src == NOREAD)) {
                cachedWriter = null;
            } else {
                cachedWriter = getWriter(src, path.getLast());

                if (cachedWriter == null) {
                    logger.warning("updateCachedWriter(): missing write method");
                }
            }
        }

        private void updateCachedValue() {
            Object src = cache[path.length() - 1];

            if ((src == null) || (src == NOREAD)) {
                cachedValue = NOREAD;
            } else {
                cachedValue = getProperty(cache[path.length() - 1],
                        path.getLast());

                if (cachedValue == NOREAD) {
                    logger.warning("updateCachedValue(): missing read method");
                }
            }
        }

        private void bindingPropertyChanged(PropertyStateEvent pse) {
            validateCache(0);

            Object oldValue = cachedValue;
            boolean wasWriteable = cachedIsWriteable();
            updateCachedBean();
            updateCachedSources(0);
            updateCachedValue();
            updateCachedWriter();
            notifyListeners(wasWriteable, oldValue, this);
        }

        private void cachedValueChanged(int index) {
            validateCache(index);

            boolean wasWriteable = cachedIsWriteable();
            Object oldValue = cachedValue;

            updateCachedSources(index);
            updateCachedValue();

            if (index != path.length()) {
                updateCachedWriter();
            }

            notifyListeners(wasWriteable, oldValue, this);
        }

        private void mapValueChanged(ObservableMap map, Object key) {
            if (ignoreChange) {
                return;
            }

            int index = getSourceIndex(map);

            if (index == -1) {
                throw new AssertionError();
            }

            if (key.equals(path.get(index))) {
                cachedValueChanged(index + 1);
            }
        }

        public void propertyStateChanged(PropertyStateEvent pe) {
            if (!pe.getValueChanged()) {
                return;
            }

            bindingPropertyChanged(pe);
        }

        private void propertyValueChanged(PropertyChangeEvent pce) {
            if (ignoreChange) {
                return;
            }

            int index = getSourceIndex(pce.getSource());

            if (index == -1) {
                throw new AssertionError();
            }

            String propertyName = pce.getPropertyName();

            if ((propertyName == null) || path.get(index).equals(propertyName)) {
                cachedValueChanged(index + 1);
            }
        }

        public void propertyChange(PropertyChangeEvent e) {
            propertyValueChanged(e);
        }

        public void mapKeyValueChanged(ObservableMap map, Object key,
            Object lastValue) {
            mapValueChanged(map, key);
        }

        public void mapKeyAdded(ObservableMap map, Object key) {
            mapValueChanged(map, key);
        }

        public void mapKeyRemoved(ObservableMap map, Object key, Object value) {
            mapValueChanged(map, key);
        }
    }
}
